Nginx-替换response header中的Content-Disposition值

我们有个需求要在打开合同PDF的时候,要将response的header里的Content-Disposition从

1
attachment;filename*="utf-8\' \'文件名"

改为

1
inline;filename*="utf-8\' \'文件名"

这样文件就可以直接在浏览器里预览打开,而不是直接下载。
理论上最好的方式自然是从应用端解决。但我们提供文件的内容管理服务器不提供这个配置选项。虽然是开源软件,但我也不想为了这个修改源代码。除此之外,为了避免影响其他和文件相关的功能,减少回归测试量,我们也不想把全局修改这个header值。
那么剩下的办法就只有从Nginx反向代理层找解决方案了。理想的解决方案是对xxx.domain.com域名(内容管理服务器的域名),所有URL中带PDF关键字和“?inline=1”参数的请求,修改header中Content-Disposition的值。(我们可以在前端请求的时候加?inline=1这个path variable)
我模糊记得Nginx可以带if条件,所以原本估计就是个小case。事实证明我估计错得离谱【捂脸】。。。如果要直接看结论的请跳转到最后一节。

教训1:Nginx“基本”不支持if里多个条件

我先找到了一段匹配文件后缀的正则表达式:

1
.*\.(后缀1|后缀2)$

后缀替换成pdf后,就尝试写了如下的代码:

1
2
3
if ($request_filename ~* ".*\.(pdf)" && $request_uri ~ "(.*)inline=1") {
# 修改header值
}

然而很快我就发现,Nginx不支持if(condition1 && condition2)的语法【捂脸】。。。
其实也有一些奇淫技巧可以实现AND和OR,比如这一篇,通过拼字符串的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
location = /test_and/ {
default_type text/html;
set $a 0;
set $b 0;
if ( $remote_addr != '' ){
set $a 1;
}
if ( $http_x_forwarded_for != '' ){
set $a 1$a;
}
if ( $a = 11 ){
set $b 1;
}
echo $b;
}

根据Nginx企业官网的一篇文章:If Is Evil,平时应该尽量谨慎用if。
除此以外,Nginx中要实现if…else…的语法也需要费一番周折。这里就不详细展开了。

教训2:location不包含参数

接下来尝试用正则表达式表现url中同时包含.pdf(不区分大小写)和“inline=1”参数。
考虑到问号可能需要转义,就用.来替代。于是写了类似如下的正则表达式:

1
location ~* ".*\.(pdf).(inline=1)"

但结果发现死活匹配不到inline=1的那段。反复尝试了多种正则表达式后,才想起来location不包含URI参数。。。
最终决定通过location匹配后缀,在location内用if匹配URI参数(inline=1):

1
2
3
4
5
6
7
location ~* ".*\.(pdf)$" {
# 省略其他
if ($args ~ inline=) {
# 替换header值逻辑
}
# proxy_pass逻辑
}

教训3:当location为正则表达式时,proxy_pass不能包含URI部分

在写proxy_pass的时候,参考了“location /”的那段逻辑,写成了:

1
proxy_pass  http://docsvr/;

nginx -s reload的时候报错:

1
2
[root@nginx-internal proxy]# nginx -s reload
nginx: [emerg] "proxy_pass" cannot have URI part in location given by regular expression, or inside named location, or inside "if" statement, or inside "limit_except" block in /etc/nginx/conf.d/proxy/doc.conf:56

查了之后才得知当location为正则表达式时,proxy_pass不能包含URI部分。在此处“/”也是URI部分。所以去除了http://docsvr/ 最后的斜杠,调整为:

1
2
3
4
5
6
7
location ~* ".*\.(pdf)$" {
# 省略其他
if ($args ~ inline=) {
# 替换header值逻辑
}
proxy_pass http://docsvr;
}

在location后使用~*是为了让后缀忽略大小写。

教训4:proxy_set_header不能包含在if语句中

接下来就是要替换Content-Disposition值了。
我们先尝试将该值替换成其他任意值:

1
2
3
if ($args ~ inline=) {
proxy_set_header 'Content-Disposition' 'bbb';
}

然后就在nginx -s reload的时候收到了报错:

1
nginx: [emerg] "proxy_set_header" directive is not allowed here in /etc/nginx/conf.d/proxy/doc.conf:32

从这篇How nginx “location if” works,我们可以知道Nginx实现if是通过一个嵌入的location。而不允许proxy_set_header很可能是因为嵌套的location不支持。
顺带提一句,除了proxy_set_header外,proxy_hide_header也不能包含在if语句中。

看上去我们只能靠变量了。逻辑大概如下:

1
2
3
4
5
6
7
set $is_inline_pdf 0
set $content_disposition 'attachment;filename*="utf-8\' \'attachement.pdf"';
if ($args ~ inline=) {
set $is_inline_pdf 1;
set $content_disposition 'inline;filename*="utf-8\' \'inline.pdf"';
}
proxy_set_header 'Content-Disposition' $content_disposition;

教训5:proxy_set_header只能用来设置自定义header

上面那段配置测试后发现无效。事实上,不管proxy_set_header给Content-Disposition设置什么值都无效。
查询之后发现proxy_set_header可能只对自定义的header有效,但不能改非自定义的header。

改用add_header替换proxy_set_header,会因为出现两个Content-Disposition而无法正常展现。在Chrome下会显示ERR_RESPONSE_HEADERS_MULTIPLE_CONTENT_DISPOSITION的报错。

所以需要用proxy_hide_header + add_header,先隐藏后添加了。即:

1
2
proxy_hide_header 'Content-Disposition';
add_header 'Content-Disposition' $content_disposition;

教训6:if语句内外的add_header不会同时生效

附带发现了一个很神奇的现象:当在命中if条件时,只有if条件内的add_header语句会执行。例如在下面的这个例子中:

1
2
3
4
5
add_header  'testa' 'aaa';
if ($args ~ inline=) {
add_header 'testb' 'bbb';
}
add_header 'testc' 'ccc';

按照我们其他语言中对if的理解,当符合条件($args ~ inline=)这个条件时,应该是testa/testb/testc三个header都会显示。
但实际上,当符合($args ~ inline=)这个条件时,只有testb这个header会显示;而如果不符合if条件时,testa和testc这两个header会显示。
原因应该也和How nginx “location if” works这篇中介绍的原理有关。

最终成果

最终语法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
set $is_inline_pdf 0;
if ($args ~ inline=) {
set $is_inline_pdf 1;
}

proxy_hide_header 'Content-Disposition';
if ($is_inline_pdf = 1) {
add_header 'Content-Disposition' 'inline;filename*="utf-8\' \'inline.pdf"';
proxy_pass http://docsvr;
}
add_header 'Content-Disposition' 'attachment;filename*="utf-8\' \'attachement.pdf"';

proxy_pass http://docsvr;

理论上要做的更好的话,可以用$request_filename或$request_uri中的文件名来替换Content-Disposition中的文件名。但实际发现Content-Disposition中的文件名不影响浏览器中显示,也不影响下载的文件名。而且要截取$request_filename中的filename所需要写的正则表达式有点变态,于是这个问题就先搁置不做优化了。

最终的感想:Nginx对if的支持太有限了。。。应该是Nginx为了解析速度和性能所必要的代价吧。

扩展阅读

在查资料的时候顺带查到一篇挺有意思的文章和一个挺有用的网站:

通过正则表达式来DDOS还挺有创意。。。
一个由正则表达式引发的血案(解决版)

看到知乎上尤雨溪推荐的JS正则可视化的工具,对理解复杂正则挺有帮助。
Regexper

本文永久链接 [ [https://galaxyyao.github.io/2019/06/17/Nginx-替换response header中的Content-Disposition值/](https://galaxyyao.github.io/2019/06/17/Nginx-替换response header中的Content-Disposition值/) ]